iT邦幫忙

2021 iThome 鐵人賽

DAY 16
0
Modern Web

從零開始學習 Next.js系列 第 16 篇

Day16 - 在 Next.js 做 JWT 驗證,使用既有的 Backend API - PART 2

  • 分享至 

  • xImage
  •  

在頁面中串接驗證 API

在前一篇文章中,我們建立了一個 JWT JSON server,用來練習如何在 Next.js 中串接 JWT 驗證,這個 JSON server 提供 POST /auth/login 的登入 API,以及 GET /products 與 GET /products/:id 兩個能夠取得產品資料的 API,而兩個產品資料 API 都必須在 header 中帶上 accessToken ,否則 JSON server 會回傳 HTTP 401,阻擋使用者獲得產品資訊。

JWT JSON server 的 repo: https://github.com/leochiu-a/fake-api-jwt-json-server

在 Next.js 中設定驗證邏輯主要是放在 API routes 中,使用 NextAuth 建立客製化的驗證流程,當使用者呼叫 NextAuth 提供的 signIn 時便會出發驗證流程,驗證成功後會得到一個 accessToken ,這個 accessToken 必須在打產品資料 API 時帶上。

現在我們已經實作完了 API routes 的部分,接下來要繼續實作在頁面中的邏輯。我們接著要實作的邏輯很單純,目標是使用者登入後可以成功瀏覽「產品列表頁面」與「產品詳細頁面」,這兩個頁面在前面的章節中已經用了很多次,這次同樣也是拿這兩個頁面來練習。

登入頁面

LoginForm 的樣式 https://gist.github.com/leochiu-a/62d2e9dce4d1a8b09905f35ca8bf4a8a

首先,我們要來撰寫一個登入的頁面 pages/login/index.tsx ,在這個頁面中會有一個 form 表單,表單中包含了使用者的 email 與 password 兩個欄位,以及一個登入按鈕,點擊之後會觸發 NextAuth 的驗證流程,驗證成功後轉址到產品列表頁面 /products 。

import { useState, SyntheticEvent } from "react";
import { useRouter } from "next/router";
import { signIn } from "next-auth/client";

import {
  AuthSection,
  Login,
  ControlItem,
  ControlLable,
  ControlInput,
  SubmitButtonWrapper,
  SubmitButton,
} from "./index.style";

const LoginForm = () => {
  // 使用者的狀態與登入邏輯...

  return (
    <AuthSection>
      <Login>Login</Login>
      <form onSubmit={handleSubmit}>
        <ControlItem>
          <ControlLable htmlFor="email">Your Email</ControlLable>
          <ControlInput
            type="email"
            id="email"
            required
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </ControlItem>
        <ControlItem>
          <ControlLable htmlFor="password">Your Password</ControlLable>
          <ControlInput
            type="password"
            id="password"
            required
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
        </ControlItem>
        <SubmitButtonWrapper>
          <SubmitButton type="submit">Login</SubmitButton>
        </SubmitButtonWrapper>
      </form>
    </AuthSection>
  );
};

export default LoginForm;

在 <LoginForm /> 中管理使用者的狀態與登入的邏輯也很單純,使用兩個 useState 分別儲存使用者的 email 與 password ,建立一個 onSubmit 的 callback function,在這個 function 中會呼叫 NextAuth 提供的 signIn() ,並指定是客製化的驗證流程 credentials ,匹配的是 NextAuth 在 API routes 設定的 Providers.Credentials 。

在驗證成功後,使用 useRouter() 轉址到產品列表頁面 /products 。

import { useState, SyntheticEvent } from "react";
import { useRouter } from "next/router";
import { signIn } from "next-auth/client";

import {
  AuthSection,
  Login,
  ControlItem,
  ControlLable,
  ControlInput,
  SubmitButtonWrapper,
  SubmitButton,
} from "./index.style";

const LoginForm = () => {
  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");

  const router = useRouter();

  const handleSubmit = async (event: SyntheticEvent) => {
    event.preventDefault();

    const result = await signIn("credentials", {
      redirect: false,
      email,
      password,
    });

    if (result?.ok) {
      router.push("/products");
    }
  };

  // return component
};

export default LoginForm;

在產品列表頁面使用 SSR + JWT 取得資料

在這個頁面中的邏輯與在前面章節中看到的大同小異,比較不一樣的是在 getServerSideProps 中的邏輯。在使用者登入後,如果想要取得 accessToken 則可以使用 NextAuth 提供的 getSession() ,這個 function 必須帶入由 getServerSideProps 的參數 ctx ,這樣才能取得使用者的驗證訊息。

然後在取得 accessToken 後,要在 fetch 的 headers 中設定 JWT 的驗證訊息,否則打產品列表 API 伺服器會回傳 HTTP 401,禁止我們取得資料。最後,成功取得資料後,把 products 當作 props 傳入到 component 中,現在應該可以順利看到產品列表頁面。

import { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/client";

import ProductCard from "../../components/ProductCard";
import { Product } from "../../fake-data";
import { PageTitle, ProductGallery } from "./index.style";

interface HomeProps {
  products: Product[];
}

const Home = ({ products }: HomeProps) => {
  return (
    <>
      <PageTitle>商品列表</PageTitle>
      <ProductGallery>
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </ProductGallery>
    </>
  );
};

export async function getServerSideProps(ctx: GetServerSidePropsContext) {
  const session = await getSession(ctx);

  const res = await fetch(`http://localhost:8000/products`, {
    headers: {
      Authorization: `Bearer ${session?.accessToken}`,
    },
  });

  const products = await res.json();

  return {
    props: {
      products,
      session,
    },
  };
}

export default Home;

使用 Context 優化取得 session 的方式

根據 NextAuth 的說明,我們可以在 /pages/__app.ts 中新增 context provider,這樣做可以提升取得 session 的效率,例如從 useSession 的原始碼中就可以看到,它會先嘗試從 context 中取得 session,如果沒有 context 再走類似 getSession 的流程,會自動打 /session API 從伺服器中取的驗證資訊。

由此可知,沒有 Provider 的設定是會在切換頁面時,如果頁面中剛好有 useSession ,會讓頁面多打很多次 /session API,造成每次使用者都必須等待一段時間後才看得到內容。

import { AppProps } from "next/app";
import { Provider } from "next-auth/client";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Provider session={pageProps.session}>
      <Component {...pageProps} />
    </Provider>
  );
}

export default MyApp;

但是,實際上 Provider 有作用的前提是一個頁面是必須是 SSR,也就是使用 Next.js 的 getServerSideProps 或 getInitialProps ,讀者也可以從上述中看到 session 是透過 pageProps.session 取得,所以如果一個頁面不是 SSR,就不能得到 context API 的幫助, useSession 還是會照常打 API。

export async function getServerSideProps(ctx: GetServerSidePropsContext) {
  return { session: await getSession(ctx) };
}

// 或者
Page.getInitialProps = async (ctx) => {
  return { session: await getSession(ctx) };
};

在產品列表頁面使用 SWR + JWT 取得資料

最後,我們要來實作「產品詳細頁面」中的邏輯,在這個頁面中我們使用的是 client-side rendering,並且使用到 useSWR 這個 API。

基本上邏輯與前面提到 CSR 的實作差不多,只差在我們會用到 useSession 取得使用者驗證資訊,將 accessToken 帶入到 fetch 的 header 中。

這邊要特別注意的是,我們傳入到 fetcher 中的數值變成是一個物件,這個物件包含 id 與 accessToken ,而 useSWR 觸發 fetcher 的時機是看傳入的第一個參數 key 是否改變,而如果每次渲染時 params 的記憶體位置都不同,將會導致 key 改變,最終導致 API 被無限次呼叫。

所以,為了解決這個問題要使用 useMemo 將 params 記憶起來,再重新渲染時不會造成 useSWR 傳入的第一次參數記憶體位置改變。

import { useMemo } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { useSession } from "next-auth/client";
import useSWR from "swr";

import { Product as ProductType } from "../../fake-data";
import ProductCard from "../../components/ProductCard";
import { PageTitle, ProductContainer, BackLink } from "./[id].style";

type Params = {
  id: string;
  accessToken: string;
};

const fetcher = (url: string, { id, accessToken }: Params) => {
  return fetch(`http://localhost:8000${url}/${id}`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  }).then((res) => res.json());
};

const Product = () => {
  const router = useRouter();
  const { id } = router.query;
  const [session, loading] = useSession();

  const params = useMemo(
    () => ({ id, accessToken: session?.accessToken }),
    [id, session]
  );

  const { data: product, error } = useSWR<ProductType>(
    id && !loading ? ["/products", params] : null,
    fetcher
  );

  if (!product || error) return <div>loading</div>;

  return (
    <>
      <PageTitle>商品詳細頁面</PageTitle>
      <BackLink>
        <Link href="/products">回產品列表</Link>
      </BackLink>
      <ProductContainer>
        <ProductCard product={product} all />
      </ProductContainer>
    </>
  );
};

export default Product;

Reference


上一篇
Day15 - 在 Next.js 做 JWT 驗證,使用既有的 Backend API - PART 1
下一篇
Day17 - 把 Next.js 部署到 Vercel 上吧!
系列文
從零開始學習 Next.js 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言